Skip to content

Stop autosave from stalling the game at high speed - #260

Merged
genixpro merged 5 commits into
masterfrom
fix-autosave-stall-at-high-speed
Sep 14, 2026
Merged

genixpro merged 5 commits into
masterfrom
fix-autosave-stall-at-high-speed

Conversation

@genixpro

@genixpro genixpro commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

At 8x, 13x and 40x the game runs smoothly for a second or two, then freezes for a fraction of a second, over and over. The freeze is the autosave. Every 256 ticks GameGUI::syncStep serialized the whole game, including every cached pathfinding gradient, and wrote it to Auto_save.game on the game thread. Late-game saves reach 65–160 MB, and at 8x 256 ticks is only about 1.3 seconds.

Changes

  1. Bulk gradient writes. Gradient fields go out as one run of bytes through a new OutputStream::writeUint16Sections. Each value used to cost three virtual calls, a name string the binary stream ignores, and its own 2-byte SHA1 update. The output bytes and SHA1 are identical; TextOutputStream keeps the per-value loop.
  2. Autosaves are hashed on the writer thread. Game::save can leave the whole-file SHA1 for later, recording the hashed range and the header bytes as first written in a DeferredGameSHA1. The BackgroundFileWriter applies it on its worker just before the write, so an autosave's bytes, SHA1 included, are identical to an inline-hashed save, and the game thread does no hashing.
  3. MapHeader::operator== compares the SHA1 the right way round. It has been inverted since 8cf81c9 (2008): std::equal(...)==0 was true only when the hashes differed. Engine::haveMap, its only caller, so re-downloaded identical files and trusted a different file with the same name, team count and map offset. A client joining a YOG game now keeps its local copy exactly when it matches the host's.
  4. Autosave spacing follows the speed preset. Saves stay about 10 seconds of real time apart: every 256 ticks at 1x, 2,048 at 8x, and 10,240 at 40x or maximum.
    • Normal speed saves on the same ticks as before (79, 335, …).
    • Counting from the last save also stops a soft-paused game, stopped on a save tick, from autosaving every frame.
  5. The disk write moves off the game thread.
    • Where the work happens: the game is still serialized between ticks, into memory. A new GAGCore::BackgroundFileWriter then replaces the file through FileManager::writeAtomically on a worker thread.
    • Overlapping saves: a snapshot that hasn't started writing is replaced by a newer one.
    • Thread lifetime: the worker exists only while there's something to write, so waiting leaves no thread behind and a forked process never inherits one.
    • Waits: the engine waits for a pending write before a session ends, before an in-game save, and when GameGUI is destroyed.
    • Faster memory stream: MemoryStreamBackend appends past its end instead of resizing, which zero-filled every byte before writing it. It also gains reserve() and takeContents(), so the snapshot moves to the writer without a copy.
  6. Setting to turn autosave off: Settings → Gameplay → Autosave (autosaveGames, on by default), translated for every language.

For review: behavior changes

  • Autosave cadence. At faster presets autosaves are fewer in ticks but the same in real time. A crash at 8x can lose up to ~2,048 ticks, about the same ~10 seconds of play as at 1x.
  • The new setting is a player-facing feature.
  • Joining a YOG game. With the comparison fixed, a client whose local file matches the host's skips the download, and one whose file differs downloads it. Before, both went the other way.
  • Timing of the file update. Autosave now reaches the disk shortly after its tick instead of within it. The late-game writes took 30–70 ms median, up to 187 ms.

Compatibility

  • Simulation: unchanged. gd-bigarena-long for 20,000 ticks ends on master's checksum, 5abcb33f. A 3,000-tick run ends on 2a2689ed with autosave on and off.
  • Save format and fields: unchanged. The final background-written Auto_save.game is byte-identical to the one this PR's previous commit wrote synchronously.
  • Replays: the 20,000-tick replay is byte-identical to master's. The autosave-on and autosave-off replays are byte-identical to each other.
  • Network protocol: no message changes. The SHA1 comparison is local to the joining client, so an old client keeps its inverted comparison against a new host and vice versa; neither side's messages change.

Verification

All results are from this branch's final tree.

  • Harnesses:
    • BufferedFileStreamHarness passes. It now also checks that MemoryStreamBackend overwrites, appends, zero-fills seek gaps and hands over its contents.
    • SavegameSafetyHarness passes, with new checks:
      • background writes keep the newest snapshot with its own finish step, finish on destruction and continue after a failed write
      • the background-written autosave, SHA1 included, matches an inline-hashed save written straight to a file
      • with a stale map offset, so the header backpatch rewrites hashed bytes, a deferred SHA1 still matches the inline one
      • Engine::haveMap trusts the saved file for its own header and rejects it when the host's SHA1 differs by one bit
      • with autosave turned off, the previous save stays untouched
    • Linux CI runs both harnesses; Windows CI runs the savegame harness.
  • Settings: test/run-settings-tests.py and test/run-game-speed-tests.py --settings-only pass. SettingsScreenTest checks that the Autosave toggle persists off and back on.
  • Translations: data/check_translations.py --strict gives the same report as before this change, and test/test_translations.py passes.
  • Test suite: scons -C test builds, and TestsRunner (187 tests), WinningConditionsHarness and ReplayStepCounterTest pass.
  • Server build: succeeds.
  • Negative controls: restoring the inverted operator== fails the haveMap check; hashing the backpatched header instead of the recorded one fails the stale-offset check.

Test games

  • games/gd-bigarena-long.game: Oazis, 256×256, 11 Castor/Warrush teams.
  • A no-Warrush game on the same map:
    GLOB2_TEST_SEED=7 glob2 -test-games-nox 1 --map Oazis --matchup nicowar,econo,nicowar,econo,nicowar,econo,nicowar,econo,nicowar,econo,castor --save-game-as <file>

How the runs were done: release build, glob2 --nox <game> <ticks> 1, with a disposable HOME whose preferences.txt sets gameSpeed and, for the on/off runs, autosaveGames.

Game-thread stall per autosave

Method:

  • Inputs: late-game autosaves of the two test games, each loaded and run for 35 seconds at 1x.
  • Instrumentation: local tick profiling that isn't part of this PR.
  • Excluded: the first autosave after loading, which has no size hint yet.
  • Measured before hashing moved to the writer thread. The game thread did no hashing in either version, and now only additionally serializes the small map header twice, so these figures still describe it; they were not re-run.
loaded save written on the game thread (previous commit) written in the background (this commit)
gd-bigarena-long, 84 MB 52 ms median, 102 ms worst (31 saves) 27.6 ms median, 39.6 ms worst (34 saves)
no-Warrush game, 128 MB 90 ms median, 138 ms worst (23 saves) 36.4 ms median, 47.2 ms worst (27 saves)
  • Previous-commit column: measured in an experiment build that ran both save paths on the same loaded saves.
  • What the game thread still does: serialize the game into memory.
  • Master, for scale: the stall averaged 260 ms (worst 435 ms) and 378 ms (worst 773 ms) over full 20,000-tick runs of the same two games. That is a different measure, so it isn't in the table.

Limits

  • Remaining stall: each late-game autosave still holds the game thread for about 30–50 ms while it serializes into memory, roughly every 10 seconds of real time.
  • Memory: a late-game snapshot, up to about 160 MB, stays in memory until it's written.
  • At 40x and maximum: the 10,240-tick interval assumes the machine keeps up. A game that can't run that fast saves less often in real time.
  • Translations: the two new setting strings are machine-drafted for all 33 languages. The Arabic help text was converted to presentation forms by script, to match that catalog. They need review by native speakers.
  • Timing: measured on one macOS machine only.
  • Interrupted writes: if the process dies while an autosave is still being written, that snapshot is lost. The previous Auto_save.game survives, because the file is only replaced by rename.
  • Settings test in CI: CI doesn't run test/run-settings-tests.py, which covers the new toggle. It passed locally.
  • Not tested: a manual two-client YOG join. haveMap is covered directly in the harness, not through a live join.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LPPkz7Vd7QLMsbQfqy3kHU

genixpro and others added 3 commits September 11, 2026 14:50
At 8x-40x the game froze for a fraction of a second every second or two.
Every 256 ticks GameGUI::syncStep wrote the whole game, including every
cached pathfinding gradient, to Auto_save.game. Late-game saves are
65-120 MB, and at 8x 256 ticks is about 1.3 seconds.

- Write gradient fields as one run of bytes through
  OutputStream::writeUint16Sections. The bytes and SHA1 are the same as
  per-value writes, without a virtual call, name string and SHA1 update
  per value.
- Give the atomic autosave writer a 1 MiB buffer instead of 16 KiB.
- Skip the whole-file SHA1 for autosaves; the header keeps zeros.
  Engine::haveMap, the hash's only reader, now fetches the host's copy
  of any file without a hash instead of trusting a local file.
- Scale the autosave interval with the game-speed preset so saves stay
  about 10 seconds of real time apart. Normal speed keeps ticks 79,
  335, and so on. Counting from the last save also stops a soft-paused
  game on a save tick from autosaving every frame.

Simulation checksums and replays are unchanged. At normal speed the
autosave differs from master's only in its 20 SHA1 bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPPkz7Vd7QLMsbQfqy3kHU
Autosave serialized the game and wrote it to disk on the game thread. At
late-game sizes (65-160 MB) the write alone took 30-70 ms, with spikes
past 180 ms, on every autosave.

- Serialize between ticks into memory, then hand the bytes to a new
  GAGCore::BackgroundFileWriter, which replaces the file through
  FileManager::writeAtomically on a worker thread. A snapshot that has
  not started writing is replaced by a newer one. The worker exists only
  while there is something to write, so waiting leaves no thread behind
  and a forked process never inherits one.
- MemoryStreamBackend appends past its end instead of resizing, which
  zero-filled every byte before writing it, and gains reserve() and
  takeContents() so the snapshot moves to the writer without a copy.
- Wait for a pending autosave before a session ends, before an in-game
  save, and when GameGUI is destroyed.
- Add Settings > Gameplay > Autosave (autosaveGames, on by default),
  translated for every language.
- Autosave now hands writeAtomically one complete buffer, which bypasses
  the stream buffer, so the 1 MiB atomic-write buffer from the previous
  commit no longer has any effect; revert it.

Saved bytes, simulation checksums and replays are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPPkz7Vd7QLMsbQfqy3kHU
Two append/append conflicts: independent constant blocks in EngineTiming.h
(autosave cadence here, gradient rebuild interval on master) and a duplicate
Version.h include in SavegameSafetyHarness.cpp. Kept both constant blocks;
kept the single existing Version.h include.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5GEG5t9ithd47SFm6PTDZ
@genixpro

Copy link
Copy Markdown
Contributor Author

Merged current master (95e24a76) via 3d9909649 to clear the conflict.

Two files conflicted, both append/append:

  • src/EngineTiming.h — this branch's autosave cadence constants vs master's GRADIENT_DIRTY_REBUILD_TICKS (from Let a cached route field notice the ground moved, wherever it moved #232). Independent blocks; kept both.
  • test/SavegameSafetyHarness.cpp — master added an #include "Version.h" that the file already had a few lines above. Kept the single existing include plus this branch's BackgroundFileWriter.h.

No production logic was touched by the resolution.

Verified locally on the merged head (macOS / Apple M3) before pushing:

  • scons -j8 release=1 server=0 savegame-safety-test builds clean.
  • python3 test/run-savegame-safety-tests.py build/src/SavegameSafetyHarness — 15 PASS, exit 0.

CI is running on 3d9909649. Still needs a review; requesting a look from @Giszmo.

@Giszmo

Giszmo commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

If savegames are up to 160MB, maybe we should save them differently? In a perfect world we could store replays with a timestamp but of course, every logic change breaks such savegames. We could also decide that gradients don't get saved and get computed first thing loading a savegame. That's probably besides the point of this PR but it would be the simpler change.

  1. No whole-file SHA1 for autosaves. The header keeps zeros instead. The only reader of that hash is Engine::haveMap, when a client joins a YOG game. It now treats a file without a hash as unverifiable and downloads the host's copy, instead of trusting a local file with the same name. Manual saves, maps and the replay header still hash.

Why not compute the hash instead of downloading the map? It's 200ms to compute a 160MB hash but uploads from the host to 3 players ... takes much longer.

  • Overlapping saves: a snapshot that hasn't started writing is replaced by a newer one.

If that happens, something stops writing the savegame within 10s, so it might happen continuously. What happens if the prior has started writing but not ended? Will the next one skip and we drop to every 20s? I guess that would be a fair logic and probably never trigger.

  • Autosave hash. Autosave files carry a zero SHA1.

Can't the thread saving the game store that hash? It's literally dirt cheap. If you threw away data needed to hash, it's ok to store a different hash as obviously the data is not needed to play the game. Just to verify it a map was distributed already, so the recipient should be able to hash and verify anyway ...

  • Joining from an autosave. A client joining a YOG game hosted from an unhashed save always downloads the file.

NACK

genixpro and others added 2 commits September 14, 2026 08:12
…way round

Autosaves had dropped their whole-file SHA1 to keep hashing off the game
thread, which made a client joining from an autosave always download it.
Game::save now records what the hash needs in a DeferredGameSHA1 and the
BackgroundFileWriter applies it on its worker, so autosave bytes match an
inline-hashed save again and Engine::haveMap is back to master's logic.

MapHeader::operator== has compared the SHA1 inverted since 8cf81c9, so
haveMap re-downloaded identical files and trusted different ones. Fixed,
with a harness check that fails on the old comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WePqccHKmAjY6cwoqEcvBE
@genixpro

Copy link
Copy Markdown
Contributor Author

@Giszmo — took your direction; 4e0014fc7 addresses each point.

Hash on the writer thread, no more download. Autosaves carry their SHA1 again. Game::save can now leave the hash for later: it records the hashed range and the header bytes as first written (the header is backpatched with the map offset afterwards, and the hash covers the pre-patch bytes) in a DeferredGameSHA1. The BackgroundFileWriter runs that on its worker just before the write. The game thread does no hashing, and the resulting file is byte-identical to an inline-hashed save; the harness checks that, including a case where the backpatch actually changes hashed bytes. Engine::haveMap is back to master's logic, so the NACKed "always download from an unhashed autosave" behaviour is gone.

And the comparison it relies on is fixed here too. MapHeader::operator== has compared the SHA1 inverted since 2008 (std::equal(...)==0), so haveMap re-downloaded identical files and trusted different ones with the same name/teams/offset. Restoring the hash alone would have changed nothing in practice, so the fix is in this PR rather than a follow-up. The harness now checks haveMap directly: it trusts the saved file for its own header and rejects it when the SHA1 differs by one bit. With the old comparison restored, that check fails.

Overlapping saves. Nothing gets skipped. If a snapshot arrives while an earlier one is still being written, it waits and is written straight after the current write finishes. Only a snapshot that hasn't started is replaced by a newer one. So if writes were ever slower than 10 s, you'd get back-to-back writes of the latest state, not a drop to every 20 s. Each queued snapshot's hash step travels with it; the harness queues 50 and checks the last one lands with its own hash.

Not saving gradients / saving differently. Agreed that's worth doing and would shrink these files a lot, but it's a save-format change with its own compatibility work, so I've left it out of this PR.

Verified on macOS (Apple M3): client and server builds; SavegameSafetyHarness (all checks, plus the two negative controls above), BufferedFileStreamHarness, TestsRunner, ReplayStepCounterTest, WinningConditionsHarness, run-settings-tests.py and run-game-speed-tests.py --settings-only all pass. CI is running on the new head. Still not done: a live two-client YOG join.

@genixpro
genixpro merged commit 9196f4f into master Sep 14, 2026
3 checks passed
@genixpro
genixpro deleted the fix-autosave-stall-at-high-speed branch September 14, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants